refactor(levm): skip redundant bytecode bounds check in JUMP validation#6522
refactor(levm): skip redundant bytecode bounds check in JUMP validation#6522MegaRedHand wants to merge 3 commits intomainfrom
Conversation
`jump_targets` is built by scanning the bytecode and recording every position that is a JUMPDEST (0x5B) and not inside a PUSH literal, so a successful binary search already guarantees both in-bounds and the JUMPDEST byte. Drop the extra `bytecode.get(target)` and `value == JUMPDEST` checks.
🤖 Kimi Code ReviewIssue 1: Integer truncation bug in target casting (Line 422) The cast // Line ~422
.binary_search(&(target as u32))Fix: Validate the conversion before casting: let target_u32 = u32::try_from(target)
.map_err(|_| VMError::InvalidJump)?;
if vm.current_call_frame.bytecode.jump_targets.binary_search(&target_u32).is_ok() {Issue 2: Loss of defense-in-depth validation The original code verified both that the target index contained the While this is correct if the bytecode analysis that populates Suggestion: Either restore the explicit opcode check (at the cost of one bounds-checked memory access), or add an explicit bounds check Issue 3: The updated comment states "safe: bytecode length fits in u32", but the cast is Positive: Removal of the unused Automated review by Kimi (Moonshot AI) · kimi-k2.5 · custom prompt |
🤖 Codex Code Review
No other issues stood out in this diff. If you keep the optimization, I’d add a regression test for both Automated review by OpenAI Codex · gpt-5.4 · custom prompt |
Greptile SummaryThis PR collapses the
Confidence Score: 3/5Not safe to merge as-is — the removed bounds check was also the only guard against u32 truncation false-positives for jump targets > u32::MAX. The optimization is logically sound for targets that fit in u32, but the removed crates/vm/levm/src/opcode_handlers/stack_memory_storage_flow.rs — the
|
| Filename | Overview |
|---|---|
| crates/vm/levm/src/opcode_handlers/stack_memory_storage_flow.rs | Removed the bytecode.get(target) bounds check from jump(), which was also the sole guard against target as u32 truncation for values > u32::MAX — a false-positive jump can now succeed for targets in (u32::MAX, usize::MAX] that alias a valid jump target after truncation. |
Flowchart
%%{init: {'theme': 'neutral'}}%%
flowchart TD
A["JUMP / JUMPI opcode\ntarget = U256::try_into().unwrap_or(usize::MAX)"] --> B["jump(vm, target: usize)"]
subgraph OLD ["Old flow (removed)"]
B --> C["bytecode.get(target)\n→ None if out-of-bounds"]
C -- None --> D["InvalidJump ✗"]
C -- Some(byte) --> E["byte == JUMPDEST?"]
E -- No --> D
E -- Yes --> F["binary_search(&(target as u32))"]
F -- Err --> D
F -- Ok --> G["pc = target + 1 ✓"]
end
subgraph NEW ["New flow (this PR)"]
B --> H["binary_search(&(target as u32))"]
H -- Err --> I["InvalidJump ✗"]
H -- Ok --> J["pc = target + 1 ✓"]
end
style D fill:#f88,stroke:#c00
style I fill:#f88,stroke:#c00
style G fill:#8f8,stroke:#080
style J fill:#8f8,stroke:#080
K["⚠️ target > u32::MAX\ne.g. 0x1_0000_0000\n→ truncates to 0\n→ may alias valid entry"] -.->|"no longer rejected\nby get(target)"| H
Prompt To Fix All With AI
This is a comment left during a code review.
Path: crates/vm/levm/src/opcode_handlers/stack_memory_storage_flow.rs
Line: 419-425
Comment:
**`target as u32` truncation opens false-positive path for oversized jump targets**
`target` arrives as `usize` (from `U256::try_into().unwrap_or(usize::MAX)`), so on a 64-bit host it can legally be `0x1_0000_0000` (value 4 294 967 296, one past `u32::MAX`). Casting that to `u32` yields `0`, and if position `0` in the bytecode happens to be a `JUMPDEST`, `jump_targets.binary_search(&0).is_ok()` returns `true` — the jump succeeds even though the requested target is wildly out of bounds.
The old `bytecode.get(target)` call prevented this: it performed a bounds check against the actual bytecode length (never ≥ 4 GB in practice), so any target > bytecode length was immediately rejected before the binary search ever ran.
The invariant stated in the PR description — *"a successful binary search already guarantees the index is within the bytecode"* — holds only when `target ≤ u32::MAX`. It breaks silently for larger values because `u32`-truncation can alias a valid entry in `jump_targets`.
Consider adding an explicit guard before the cast:
```rust
// Reject targets that can't possibly be a valid bytecode index before
// the u32 cast that `binary_search` requires.
if target > u32::MAX as usize {
return Err(ExceptionalHalt::InvalidJump.into());
}
#[expect(clippy::as_conversions, reason = "safe: guarded above")]
if vm
.current_call_frame
.bytecode
.jump_targets
.binary_search(&(target as u32))
.is_ok()
{
...
}
```
Alternatively, cast via `u32::try_from(target).ok()` and short-circuit on `None`.
How can I resolve this? If you propose a fix, please make it concise.Reviews (1): Last reviewed commit: "perf(levm): skip redundant bytecode boun..." | Re-trigger Greptile
Lines of code reportTotal lines added: Detailed view |
There was a problem hiding this comment.
Pull request overview
This PR optimizes LEVM JUMP/JUMPI validation by relying solely on the precomputed jump_targets list to determine whether a jump destination is valid, removing a redundant bytecode bounds/opcode check.
Changes:
- Replace
bytecode.get(target)+Opcode::JUMPDESTcomparison with a singlejump_targets.binary_search(...).is_ok(). - Remove the now-unused
opcodes::Opcodeimport. - Update the Clippy
as-cast expectation reason fortarget as u32.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| #[expect(clippy::as_conversions, reason = "safe: bytecode length fits in u32")] | ||
| if vm | ||
| .current_call_frame | ||
| .bytecode | ||
| .bytecode | ||
| .get(target) | ||
| .is_some_and(|&value| { | ||
| value == Opcode::JUMPDEST as u8 | ||
| && vm | ||
| .current_call_frame | ||
| .bytecode | ||
| .jump_targets | ||
| .binary_search(&(target as u32)) | ||
| .is_ok() | ||
| }) | ||
| .jump_targets | ||
| .binary_search(&(target as u32)) | ||
| .is_ok() |
There was a problem hiding this comment.
target as u32 can truncate on 64-bit platforms when the stack-provided jump target is in (u32::MAX, usize::MAX]. In that case binary_search may succeed for the truncated value even though the real target is out of bounds, and the VM will set pc to the large target (which later executes as STOP via CallFrame::next_opcode), changing InvalidJump semantics. Use a checked conversion (e.g., u32::try_from(target)) and treat conversion failure as an invalid jump, or explicitly reject target > u32::MAX before the binary search.
🤖 Claude Code ReviewNow I have all the context needed. Here is the review: PR Review:
|
| Step | Old code | New code |
|---|---|---|
bytecode.get(4_294_967_396) |
None → condition false → InvalidJump |
(not called) |
(4_294_967_396_usize as u32) |
never reached | 100 — matches position in jump_targets |
| Result | Err(InvalidJump) — correct |
Ok(()), pc = 4_294_967_397 — wrong |
After the false positive, pc is set to a value far outside the bytecode. How the VM subsequently handles that out-of-bounds pc determines the full blast radius, but at minimum this is a deviation from the EVM specification (a JUMP to an out-of-range destination must produce an exceptional halt, not silently succeed).
Fix — use u32::try_from instead of as:
#[expect(clippy::as_conversions, reason = "...")] // no longer needed once this is fixed
if u32::try_from(target).map_or(false, |t| {
vm.current_call_frame
.bytecode
.jump_targets
.binary_search(&t)
.is_ok()
})Or, equivalently, restore a cheap bounds check first:
if target < vm.current_call_frame.bytecode.bytecode.len()
&& vm.current_call_frame
.bytecode
.jump_targets
.binary_search(&(target as u32))
.is_ok()Either approach eliminates the truncation hazard and removes the need for #[expect(clippy::as_conversions)] entirely.
Minor Points
-
The clippy suppression reason is misleading.
"safe: bytecode length fits in u32"is a fact aboutCode::jump_targetsentries, buttargetis a runtime EVM stack value — the two are unrelated. The comment implies the cast is safe, which it is not fortarget > u32::MAX. -
No new test for the large-target edge case. A test that calls
jump()with a target ofu32::MAX as usize + 1(where position0is a JUMPDEST) would pin the correctInvalidJumpbehaviour and prevent this regression from going unnoticed in the future. -
Correctness of the core optimisation. The reasoning in the PR description is sound:
compute_jump_targetsguarantees that every stored index satisfies both the in-bounds and the JUMPDEST-byte conditions, sobinary_searchalone is sufficient for targets that fit inu32. The optimisation is valid once the truncation issue is addressed. -
wrapping_add(1)onpc. Not introduced by this PR and not a regression here — sincejump_targetscan never containu32::MAXfor any practical bytecode, the only waytarget == usize::MAXcould reach the success branch is through the truncation bug described above.
Summary
The optimisation idea is correct and the cleanup is welcome, but the removal of bytecode.get(target) inadvertently removed the implicit upper-bound guard that made target as u32 safe. For any EVM target value in (u32::MAX, u64::MAX] whose lower 32 bits coincide with a valid JUMPDEST position, the new code accepts the jump where the old code (and the spec) require an exceptional halt. This should be fixed with u32::try_from(target) before merging.
Automated review by Claude (Anthropic) · sonnet · custom prompt
Benchmark Results ComparisonNo significant difference was registered for any benchmark run. Detailed ResultsBenchmark Results: BubbleSort
Benchmark Results: ERC20Approval
Benchmark Results: ERC20Mint
Benchmark Results: ERC20Transfer
Benchmark Results: Factorial
Benchmark Results: FactorialRecursive
Benchmark Results: Fibonacci
Benchmark Results: FibonacciRecursive
Benchmark Results: ManyHashes
Benchmark Results: MstoreBench
Benchmark Results: Push
Benchmark Results: SstoreBench_no_opt
|
Benchmark Block Execution Results Comparison Against Main
|
Motivation
In
jump(), target validity was checked twice:bytecode.get(target)to fetch the byte and verify in-bounds.Opcode::JUMPDEST as u8.jump_targets.But
Code::compute_jump_targets(incrates/common/types/account.rs) already scans the bytecode and only records positions whose byte is exactlyJUMPDEST (0x5B)and that are not inside a PUSH literal. So a successful binary search already guarantees both:JUMPDEST.The explicit
get(target)bounds check and the byte comparison are therefore redundant.Description
jump_targets.binary_search(...).is_ok().opcodes::Opcodeimport.jump_targetsalone).#[expect(clippy::as_conversions, reason = ...)]message to explain why thetarget as u32cast is safe (bytecode length fits inu32).Origin: cherry-picked optimization by @jrchatruc (commit
dbf40f4), with the original documentation preserved.